事故報告寫到一半卡住的那種問題:「上週三下午那批品質很怪的回覆,當時線上跑的是哪一版 prompt?」如果你的 system prompt 是散在程式碼裡的字串常數,這題答不出來。今天要做的事很小(把 prompt 搬進自己的檔案、給它一個 version 欄位、讓每次呼叫的 log 帶上版本標籤和內容雜湊),但背後的立場值得講清楚:prompt 是會出事故的生產資產,它需要資產該有的待遇。
讀完你會知道 prompt 的家長什麼樣、為什麼 version 欄位不能只靠 git、以及 Day 7 結尾那個懸念的答案:system prompt 既然不進歷史,它每輪從哪裡來。
先看反面。prompt 以字串常數活在程式碼裡的時候,會發生這些事:改一個措辭要動 Python 檔;prompt 跟邏輯攪在同一個 diff 裡,review 的人分不出「行為變了」還是「話術變了」。搬出來之後 release gate 照跑(prompt 變更本來就該過測試),省下的不是 CI,是 review 時的認知成本:diff 的 ownership 一眼可辨。
更根本的是身分問題。字串常數沒有名字、沒有版本:它的「版本」就是整個應用程式的版本。當回覆品質出狀況,你只能從部署紀錄反推「那個 commit 裡的那個字串長什麼樣」,而不是直接問「這輪呼叫用了哪版 prompt」。
還有一層是 Day 7 剛定下的事實:transcript 只有 user/assistant,provider replay history 裡有 reasoning 等 output items——兩邊都沒有 system prompt 的位置。它不落庫、不隨對話保存,意思是它的生命週期本來就跟對話無關,得獨立管理。既然要獨立管理,就先給它一個家。
我們的做法:prompts/ 目錄下一個 Markdown 檔一個 prompt,YAML front-matter 帶身分,本文就是 prompt 內容。這是 day-08 tag 上的 prompts/default_chat.md:
---
name: default_chat
version: 1
description: General-purpose backend assistant system prompt
changelog:
- "v1: initial front-matter version (Day 8)"
---
You are a helpful backend engineering assistant.
Answer concisely and clearly.
欄位固定四個,各有一個理由,缺一個就少一種能力:
name:資產要能被指認。它必須與檔名(去 .md)一致。名字寫在兩個地方就會分岔,loader 直接驗證。version:int,人工遞增,每次語意變更 +1。它是事故回溯的鑰匙,下一節專講。description:一句話用途。半年後開這個目錄的人(多半是你自己)需要它。changelog:每版一行的人話摘要。git 是完整履歷,changelog 是 release note。「v3 收緊了語氣」比一串 diff 好讀。沒有 variables 欄位、沒有模板語法,這是刻意的,被否決的方案一節會回來講。
載入端是 prompts/loader.py,核心是一個 frozen dataclass 加一個嚴格驗證的 load_prompt:
@dataclass(frozen=True)
class PromptTemplate:
"""``name@version`` is the human-readable release label a person chooses;
``sha256`` is content provenance computed from the bytes — a forgotten
version bump cannot lie about what text was actually sent upstream."""
name: str
version: int
description: str
text: str
sha256: str
sha256 不在 front-matter 裡:它是 loader 載入時對 prompt 本文算出來的,front-matter 仍然只有四欄位。人填的是標籤,機器算的是指紋。
驗證是全有全無的 closed schema:缺欄位、多出四欄位以外的未知欄位、壞 YAML、version 不是 int 或小於 1、name/description/changelog 為空、name 與檔名不一致、本文為空,任何一種都拋 PromptTemplateError。「固定四欄位」是 loader 強制的合約,不是紙上的君子協定。錯誤的爆炸時機則由呼叫位置決定:build_chat_service(Day 4 立的組合點)在啟動期載入一次:
def build_chat_service(settings: Settings) -> ChatService:
"""Composition point: the only place that decides fake vs. real."""
# Fail fast: a malformed template must kill startup, not the first request.
prompt = load_prompt("default_chat")
這沿用 Day 5 的 fail-fast 慣例:壞模板讓 process 在啟動階段失敗,而不是等第一個 request 才回 500。舊版是否能持續服務、平台是否自動 rollback,仍取決於 readiness probe、rolling deployment 與 rollout policy;loader 能保證的是壞模板不會讓新 process 成功啟動。
「prompt 進了 git,版本不就有了嗎?」這是這個設計最常被挑戰的點,值得正面回答。
git 記的是檔案的歷史,但事故發生在執行體上。線上那個 process 手裡只有載入時讀進來的字串;要從「幾點幾分的 log」對回「當時的 prompt 內容」,得穿過部署 pipeline、image tag、rollback 紀錄。build SHA / image digest 做得到這件事(對 immutable artifact 而言,它能唯一定位 artifact 裡的每一個 byte,是最強的 provenance),但它是一段跨系統的查詢,而且沒有人記得住「a3f9c21 的 prompt 是哪一版措辭」。
所以我們讓資產在 log 上自我描述,而且是兩個層次:version 是人工維護的 release label,人類可讀、方便 grep 和溝通(「v3 收緊了語氣」);prompt_sha256 是載入時對 prompt 本文算出的內容雜湊,它不依賴任何人的紀律。內容變了、雜湊必變,忘記 bump version 也騙不了它。
version 給人看,hash 給查證用,兩者互補而不是互相替代;build SHA 依然是更外圈的 provenance,這裡只是把「這輪呼叫用了哪段 prompt」變成單一 log 行內就能回答的問題。這是 day-08 tag 上 services/azure_openai.py 的實際 log 函式:
def _log_llm_call(prompt: PromptTemplate | None, streaming: bool) -> None:
# Attribution over metrics: incidents must be able to answer "which
# prompt version was live on this request?" without asking git.
prompt_name = prompt.name if prompt else None
prompt_version = prompt.version if prompt else None
prompt_sha256 = prompt.sha256 if prompt else None
prompt_sha256_prefix = prompt_sha256[:12] if prompt_sha256 else None
correlation_id = correlation_id_var.get()
logger.info(
"llm call streaming=%s prompt_name=%s prompt_version=%s prompt_sha256=%s correlation_id=%s",
streaming,
prompt_name,
prompt_version,
prompt_sha256_prefix,
correlation_id,
extra={
"prompt_name": prompt_name,
"prompt_version": prompt_version,
"prompt_sha256": prompt_sha256,
"correlation_id": correlation_id,
},
)
行內放 12 碼前綴(grep 夠用、行不爆長),extra= 裡放完整雜湊給 structured log 後端。
第一版曾把這幾個欄位只塞進 extra=,指望 formatter 印出來,結果 configure_logging 用的 format string(%(message)s)根本不讀 extra,實際印出的行只有 llm call (streaming=False),prompt_version 不在行上,grep 撈不到。
修好的版本把欄位直接寫進 message 本體,extra= 留著給看 structured log 的後端用;rendered line 和 structured attributes 兩邊都有,才經得起「用生產方式啟動再看一眼」的驗收。
correlation_id_var 是 Day 3 的 correlation middleware 留下的 ContextVar:同一個 id 同時在 response header 和這行 log 上,一條事故回報就能對上一次 upstream 呼叫。對真實 deployment(Day 4 的 chat-mini)實測(2026-07,識別資訊已遮蔽),server log 長這樣:
2026-07-22 INFO azgenai_lab.services.azure_openai llm call streaming=False prompt_name=default_chat prompt_version=1 prompt_sha256=3fc600212f32 correlation_id=6087a8a2-6004-42fa-84b9-e3cf7761c824
而同一個 request 的 response header 是 x-correlation-id: 6087a8a2-6004-42fa-84b9-e3cf7761c824,對上了。「上週三那批怪回覆」現在的查法是:拿使用者回報的 correlation id 撈 log,prompt_version 和 prompt_sha256 直接寫在行上。標籤告訴你「應該是哪版」,雜湊告訴你「實際是哪段內容」。
一個刻意的不做:版本不放 response header、不進 API contract。prompt 用哪版是內部管理細節,外洩它等於把內部資產結構變成 client 可依賴的東西。這也解釋了本日 milestone 的一個驗收條件:OpenAPI 零 diff。instructions 注入、版本 logging,整套改完 HTTP contract 一個字都沒動;contract 不動,Day 3 以來的 BDD 情境也一條不用加;prompt 有沒有真的上到呼叫,靠的是分層的證據(下一節收尾會把三層攤開)。
家有了、版本有了,接下來是 Day 7 的懸念:system prompt 不在 transcript、不在 replay history,那模型每輪怎麼拿到它?
Responses API 給了一個原生欄位:instructions。它把一則 system(或 developer)message 插入模型的 context,而且是 per-call 的。
官方 reference 明文寫著:搭配 previous_response_id 使用時,前一輪的 instructions 不會帶到下一輪,每輪都要重新給(查核 2026-07,OpenAI Responses API reference)。
這個「不沾黏」的語意跟我們的架構天生對齊:store=False 之下每輪本來就是獨立呼叫,prompt 每輪現場注入。
response = await self._client.responses.create(
model=self._deployment_name, # still the deployment name
input=_to_input(items),
# system prompt travels per call, never in history (Day 8)
instructions=self._prompt.text,
store=False, # state ownership stays with us: ConversationStore (Day 7)
另一條路是把 system message 塞進 input 首項,讓歷史的第一筆永遠是 system role。我們否決它,理由有兩個。
第一,它放棄了 API 的原生欄位去手工模擬同一件事,還得自己維護「system 永遠在第一位」的不變量。第二,更危險的是它誘使 system prompt 落庫:一旦 prompt 混進 history 的資料結構,離「跟著對話存起來」只剩一步之遙。存了,改 prompt 就改不動舊對話(舊對話帶著舊 prompt),Day 7 辛苦立起來的「transcript 只有 user/assistant」也破了。instructions 讓 prompt 走呼叫參數、history 走狀態,兩條生命週期物理隔離。
「prompt 真的上了呼叫嗎」這個問題,值得把證據拆成三層講,因為它們證明的是三件不同的事。
第一層:組裝路徑。 沿用 Day 7 的 fake 標記手法。Fake 不會送任何東西給 Responses API;這個標記只證明組合點確實把 PromptTemplate 一路傳進 adapter:
if prompt is not None:
# Proves through the API that the composition path carried the
# prompt into the adapter — the fake never talks to Azure.
markers.append(f"prompt={prompt.name}@{prompt.version}")
起 fake 服務打一發(day-08 tag、Python 3.13 + uv,macOS,2026-07 實測):
curl -s localhost:8000/api/v1/chat -X POST \
-H 'content-type: application/json' -d '{"message": "ping"}'
{"message":"[fake-llm] ping (prompt=default_chat@1)","conversation_id":"fb74…","correlation_id":"…"}
第二層:SDK 呼叫參數。 真正證明 instructions=self._prompt.text 被送出去的,是 real adapter 的 capture test:用一個記錄 kwargs 的 stub client 斷言 complete() 與 open_stream() 兩條路徑的 responses.create(...) 都帶了 instructions,kwarg 被拿掉測試就掛。這層才是「送達」的證明,fake 標記替代不了它。
第三層:行為 sanity check。 切到真實路徑,問它「你是什麼樣的助理」(實測 2026-07):
{"message":"I am a helpful AI backend engineering assistant that provides concise technical
guidance, code examples, debugging help, and system design advice.",
"conversation_id":"3fc9…","correlation_id":"5a7cf0da-…"}
回覆照出了模板裡的 backend engineering assistant,但誠實地說,這一層單獨不構成證明:沒掛 system prompt 的通用模型也可能自稱 helpful assistant。它的價值是配合前兩層做端到端的 sanity check(要更高辨識度,可以臨時換一版帶 canary 短語的 prompt 來 probe)。這也是 BDD 不加情境的原因:行為層的問題屬於 live smoke,不屬於 contract。
這次 milestone 踩了一個值得單獨立一節的坑。log 函式寫了、測試綠了(caplog 斷言 prompt_name/prompt_version 都在)、PR 合了,然後 live smoke 時 server console 一片安靜,一行 INFO 都沒有。
原因:core/logging.py 裡的 configure_logging() 從 Day 3 起就定義了但沒有人呼叫。Python 的預設 logging 設定下,root logger 沒有 handler 收 WARNING 以下的訊息,uvicorn azgenai_lab.main:app 一起,應用層的 INFO log 全數靜默丟棄。測試會過,是因為 pytest 的 caplog 自己掛了 handler——測試環境替你把管線接好了,生產環境沒有。
修法一行:create_app() 啟動時呼叫 configure_logging(settings.log_level)(follow-up PR #20,day-08 tag 移到含修正的 commit)。教訓比修法值錢:沒有在啟動路徑上接起來的 observability,等於不存在。而且它的失敗模式是靜默的,不會有任何錯誤告訴你 log 正在消失。驗收 observability 只有一種方法:用生產的啟動方式起服務,親眼看到那行 log。
忍喵:「log 測試全綠、生產零輸出——因為 pytest 幫你掛了 handler,uvicorn 沒有。事故當天才發現 log 不存在的人,跟沒寫 log 的人結局一樣。」
看到「prompt 放檔案」,很自然的下一步是「那上個 Jinja 吧」。我們否決了:不是反對 Jinja,是反對在零變數的今天引進它。
現在的 prompt 沒有任何插值需求:{{ user_name }} 不存在、{% if %} 不存在。這時引進模板引擎,得到的是零、付出的是一整包:多一個依賴、prompt 檔多一層語法、多一組要想清楚的邊界條件。零收益配上真實成本,這題不難決。
上場那天要配的安全帶,得先講清楚 threat model,不能一句「有 SSTI 風險」帶過。模板注入(SSTI)的前提是不可信的內容成為 template source:攻擊者能改模板本身,或應用把使用者輸入再拿去當模板編譯。像我們這樣 template 是 repo 裡走 code review 的 trusted asset、外部資料只當 variable value 傳入,並不會因為用了 Jinja 就自動變成 SSTI。
所以安全帶分兩種。template source 始終由工程端管控時,必配的是 StrictUndefined(未定義變數直接爆錯而不是靜默輸出空字串;這是 correctness guard,防的是 prompt 缺一段沒人發現),加上「永遠不把 user/RAG content 當 template source」這條紀律;哪天允許非工程角色或外部來源編輯模板,sandbox / allowlist 才升級為必要控制。
Jinja 最早的評估點已經排定:Day 14 的 RAG 查詢管線要把檢索片段組進 context,那是真實的變數需求第一次出現的地方,屆時再決定要不要引入(引入才有 StrictUndefined 這條安全帶要繫)。到時真正的新課題也不是 SSTI,而是檢索內容的 prompt injection:文件裡藏的指令會不會被模型當成指令執行,這是 instruction 與 data 的邊界問題,模板引擎管不到,Day 21 會正面處理。
忍喵:「零個變數配一台模板引擎,跟零件都沒有先買一組扭力扳手一樣。需求出現那天再買,順便把 StrictUndefined 這條安全帶繫上。」
有一面現成的鏡子可以照:Microsoft 的 Prompty,VS Code 有官方 extension 的 prompt 資產格式(查核 2026-07)。它的結構正是 YAML front-matter(name、description、model 設定、inputs)+模板化的 prompt 內容,模板語法支援 Jinja2(或 Mustache)的 {{variable}}。
Prompty 提供了一個可比較的 Microsoft 實作:它同樣以 YAML front-matter 描述 prompt 的身分、模型設定與 inputs。這表示本篇採用檔案資產與 front-matter 的方向能與 Prompty 對照,但不代表四欄位 schema 是業界通用標準。Prompty 把 inputs/變數做成一級公民,是因為它服務的 prompt 迭代與多模型測試場景天生有變數;我們今天沒有這個需求。
另一條被問到的路:prompt 放 Azure App Configuration,集中式設定服務,改 prompt 不用重新部署,還有 feature flag 可以做漸進式發布。
它解的是真問題,但不是我們今天的問題。改 prompt 不重新部署,代價是放棄「部署即驗證」:現在壞模板會讓啟動 fail-fast,改成執行期從遠端拉,驗證失敗的處置(用舊版?拒答?)就成了新的設計題;version 的權威來源也從檔案本體移到外部服務,log 自證的鏈路多一個環節。單一模板、個位數 RPS 的今天,這些複雜度買不回對應的價值。
它的上場條件倒是清楚:prompt 改動頻率高到部署成為瓶頸、或需要不重啟的漸進式切換時,App Configuration(配 dynamic refresh)是正規解,而且我們的 PromptTemplate 介面不用改,換掉的是 load_prompt 的資料來源,組合點還是那一個。這跟 Day 7 的 ConversationStore 是同一個手法:介面先行,來源可換。
這套「檔案 + front-matter + instructions 注入 + version logging」適合的情境:prompt 數量少、改動走 code review、團隊要的是事故可回溯而不是熱更新。大多數後端服務的起點都在這裡。
不適用的情境也直說:prompt 迭代由非工程角色主導(要 UI 不要 git)、需要 A/B testing 或 per-tenant prompt 路由、改動頻率高到每次都部署撐不住。這些需要 prompt registry 或 App Configuration 那個量級的方案,本篇的設計是它們的地基,不是替代品。
目前實作的誠實邊界:
prompt_sha256 補上了內容層的自證(雜湊騙不了),但「v1 到底該對應哪個雜湊」仍然沒有 CI 強制。要補的話,內容變更必須 bump version 的 CI 檢查是條路。load_prompt("default_chat") 寫死在組合點,多 prompt 選擇是未來的事。deferred 到今天的 prompt front-matter 決策定案:四欄位(name/version/description/changelog)、loader 嚴格驗證、啟動期 fail-fast。注入走 Responses API 的 instructions,per-call、不落史,跟 Day 7 的狀態設計互相成全。
每次 upstream 呼叫的 log 帶 prompt_name/prompt_version/prompt_sha256/correlation_id:version 給人 grep 和溝通,hash 給內容自證。整套改動 OpenAPI 零 diff,prompt 管理是內部資產治理,本來就不該動 HTTP contract。
目前只有一個固定 prompt,先不引入模板引擎或遠端設定服務。Day 14 出現實際的 context 插值需求時,再連同 StrictUndefined 與 prompt-injection 邊界一起評估。完整程式碼與測試在 day-08 tag。
下一篇換一個所有人都躲不掉的維度:錢。歷史每輪重放、prompt 每輪注入,token 用量是這兩個設計的直接下游。Day 9 來做 token budget 與成本防線,把「這個 API 一個月燒多少」從猜測變成算式。
(本篇無新增雲端資源——沿用 Day 4 建立的 resource 與 deployment,純 token 計費。)
| 工程需求 | Azure / Microsoft 對應服務 | 本篇怎麼用 |
|---|---|---|
| LLM 推論 API | Azure OpenAI in Microsoft Foundry Models | 沿用 Day 4 的 chat-mini deployment,Responses API instructions 每輪注入 system prompt |
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。